昨天寫到,一條用 skill 編排的 AI 開發 pipeline,在事故與規則不斷累積之後,從 221 行長到了 419 行。
coordinator 文件已經大到沒有人敢改,我當時選擇重構:把共用規則放進 always-on rule,各階段的操作拆進不同 skill,coordinator 只負責調度。
做法和重構程式碼很像,但我漏掉了一件事:程式碼有測試,這批 Markdown 沒有。
重構後,我讓另一個 AI review 這批修改。
第一輪找到問題,我修掉;第二輪又找到新的。修到後面,review 找到的不只剩下舊問題,還包括前一輪才剛製造出來的問題。
從 Git 紀錄回頭看,8 月 19 日到 9 月 1 日之間,相關修改累積了約 40 個 fix commit,其中有十二輪明確標記的 Codex review。
幾個 commit message 已經很像事故摘要:
address review round 2 — every finding was a second copy of the old rule
retarget the 6 remaining dead citations
reconcile the last two rule copies
enforce the lifecycle guard in code
correct 8 false claims, including a regression introduced in round 11
一開始我覺得,能 review 十二輪代表檢查很仔細。
但修到第十二輪,我開始覺得不對:如果每輪修改都會再製造新的 finding,我缺的可能不是下一輪 review,而是一個能判斷行為是否正確的基準。
測試領域把這種基準叫作 test oracle:給定一個輸入,它能告訴你正確結果應該是什麼。
而這次重構裡,唯一的 oracle,是另一個 AI 再讀一次同一批散文。
第二輪 review 的 commit message 寫得很直接:
every finding was a second copy of the old rule
那一輪找到六個問題。每一個都是舊規則沒刪乾淨,還留在其他文件裡。
例如,新的流程已經決定不再把 mock 頁面提交進 Git,但 always-on rule 裡還留著「必須提交」的舊指示。
又例如,實際 gate 已經允許 src/ 以外的幾種有效變更,reference 文件卻仍然寫著只有 src/ 才算 implementation。
新規則和舊規則都寫得通順。單獨打開任何一個檔案,都不一定看得出問題。
我把整個 repository 搜尋一遍,才看到同一條規則的不同版本正在互相衝突。
更麻煩的是,這個問題沒有在第二輪結束。到了第八輪,commit message 仍然是:
reconcile the last two rule copies
檔案拆開了,同一條規則還是散落在不同地方。
重構時,我刪掉或合併了一批舊的 *-mode.md 文件。
檔案本身不見了,但其他文件裡還有六個地方叫 agent 去讀它們。
所以後來出現了這個 commit:
retarget the 6 remaining dead *-mode.md citations
這種錯誤很難靠閱讀發現。
Reviewer 必須剛好注意到某段文字是一個路徑,再真的沿著路徑走一次,才會知道目標已經不存在。
但對工具來說,這件事其實很簡單:把 Markdown 裡的本地引用抓出來,逐一檢查檔案是否存在就好。
這件事跑一次 link checker 就能確認,我卻叫 AI 靠語意理解去找。
第十二輪 review 一次修正了八個錯誤陳述。
其中一份文件說,teardown 會刪除 worktree 裡的 mock;程式碼裡的註解卻寫著,為了保留已發布的 artifact,這些 mock 會刻意留下來。
另一份文件說,某個 archive command 遇到失敗時仍會回傳成功;當時安裝的版本實際上會回傳非零 exit code。
還有一段把一個簡單的正規表示式描述成「驗證 OpenSpec 語意」,但那段程式根本沒有解析 OpenSpec,只是在比對文字。
這次的矛盾發生在文件與實際環境之間。
AI 可以把句子改得很流暢,但光讀文件,不會知道 CLI 實際回傳什麼 exit code、程式怎麼執行,也不知道機器上裝的是哪個版本。
只要環境改變,寫死在散文裡的「事實」就會開始過期。
當時的 Trello 操作文件已經寫著:不帶 lifecycle 參數的 remove-label,只能用來移除一般標籤,不能移除流程狀態標籤。
看起來規則已經存在。
但 CLI 並沒有執行這條規則。
Agent 仍然可以直接移除卡片最後一個 lifecycle label,讓卡片離開整個狀態機。文件會說這個操作不應該發生,程式卻照樣執行。
第十一輪 review 才把它改成真正的 runtime guard:不合法的操作直接失敗。
那個 commit message 是:
enforce the lifecycle guard in code
我到這裡才分清楚:「文件有寫」不等於「系統會擋」。
安全性如果還要靠 agent 記得那句話,這條規則頂多算建議,稱不上 guard。
有一輪 review 發現,同一個變數名稱 $T,在不同指令範例裡分別代表 Trello token 和 script path。
人讀上下文,大概猜得出每一段想表達什麼。但 agent 把幾段指令組合起來執行時,同名變數就可能覆蓋掉前面的值。
第一次修正把部分 $T 改名,後來又出現一個 commit,專門「完成 $T 到 $TCO 的改名」。
第一次改名也沒改乾淨,舊的 $T 又留在其他地方。
如果這些值來自有 schema 的設定,或至少經過一個檢查重複名稱的 lint,這種問題可以在執行前就被擋下來。
但當變數名稱散落在 Markdown code block 裡,它只是另一段需要 reviewer 記得搜尋的文字。
第十一輪有一個 finding:檢查 mock 檔名時,只要檔名包含卡片 slug 就算通過。
假設正確 slug 是 checkout-flow,那麼 checkout-flow-old.html 也會被誤判成正確檔案。
第十一輪把 substring match 改成精確比對,要求檔名 stem 必須完全等於 slug。
結果這個修正擋掉了另一種原本合法的結構:
public/mock/<slug>/SC1-happy-path.html
Repository 裡當時已經有 99 個這樣的巢狀頁面。
第十二輪 review 才發現,第十一輪的修正讓它們全部失效。最後規則又改成:可以是根目錄下以 slug 命名的檔案,也可以是 slug 目錄裡的頁面。
這就是 regression:一個案例修好了,原本正常的案例卻壞了。
那 99 條真實路徑本來可以直接變成測試 fixture。每次修改 gate 時跑一次,就會立刻知道哪些既有行為被改壞。
但我沒有先把它們變成測試,而是等下一個 AI reviewer 重新發現。
這十二輪 review 當然有價值。它們找到的問題大多是真的,也攔下不少原本會進入 pipeline 的錯誤。
但 review 被迫同時扮演太多角色:
| 失敗形態 | 更適合的檢查方式 |
|---|---|
| 第二份副本 | 單一資料來源、規則 ID、重複定義 lint |
| 死引用 | link checker |
| 對世界的錯誤陳述 | 從程式或設定產生文件、執行 live check |
| 只寫不擋 | 可執行的 gate 與明確 exit code |
| 名稱碰撞 | schema、命名空間、lint |
| 修 A 壞 B | characterization test 與真實 fixture |
表裡這些檢查都不用猜:檔案是否存在、指令是否成功、輸入有沒有通過,以及既有案例的結果是否改變,都能直接驗證。
AI review 更適合處理需要判斷的問題,例如規則合不合理、有沒有漏掉重要情境,或是否符合人的意圖。
它不應該被拿來代替檔案存在檢查、名稱唯一性、狀態機約束和 regression test。
能重現的 review finding,最後就該變成測試。別再多補一段文件。
只要讓另一個 AI 多 review 幾輪,就能安全地重構這條由 Markdown 組成的 pipeline。
沒有可執行的預期行為,review 就無法證明重構前後的行為相同。
十二輪 review 的另一面,是十二次用新問題換掉舊問題的機會。
下次重構,我會先列出不能改變的行為,再決定要用哪些測試證明它們還在。